Skip to content

feat: nested proxy: config, gateway hardening, and end-to-end smoke tests - #84

Merged
insomnius merged 3 commits into
masterfrom
proxy-hardening-and-smoke-tests
May 23, 2026
Merged

feat: nested proxy: config, gateway hardening, and end-to-end smoke tests#84
insomnius merged 3 commits into
masterfrom
proxy-hardening-and-smoke-tests

Conversation

@insomnius

Copy link
Copy Markdown
Collaborator

Summary

  • New nested proxy: block in app.yml consolidates host + adds two production-stability knobs (upstream_timeout, max_request_body_size). Backwards compatible with the legacy top-level proxy_host:.
  • Three real production-stability fixes in the gateway proxy: per-request timeout, request body cap, response body streamed via io.Copy (no more proportional heap spike).
  • End-to-end smoke test suite — 10 subtests driving the actual altair binary against MySQL + a mock upstream — wired into a new smoke CI job in general.yml.
  • Drop dead core/migrator.go + core/provider.go (plugin registry refactor superseded them; zero callers remained).
  • Fix the broken env.sample that crash-looped the docker-compose MySQL container (MYSQL_USER=root is rejected by MySQL 5.7).

What's new in app.yml

proxy:
  host: www.local.host         # was top-level proxy_host (still honored as deprecated alias)
  upstream_timeout: 30s        # NEW — bound the entire upstream round-trip
  max_request_body_size: 10MB  # NEW — opt-in cap, returns 413 before dialing upstream

If both proxy.host and proxy_host: are set, the nested form wins so operators can stage migrations without an outage.

Why each gateway change

Issue Old behaviour New behaviour
Hung upstream http.Client{} per request, no timeout — leaked goroutines and FDs forever Shared *http.Client with Timeout = proxy.upstream_timeout (default 30s)
Oversized client body Unbounded io.ReadAll of c.Request.Body; ~500MB heap on a 500MB POST http.MaxBytesReader with typed *http.MaxBytesError → 413 before dialing upstream
Response body io.ReadAll(proxyRes.Body) then Writer.Write — full body in heap io.Copy(c.Writer, proxyRes.Body) — streamed
Proxy host Re-read os.Getenv("PROXY_HOST") every request Captured once at NewGenerator from appConfig.ProxyHost()

Smoke test matrix

10 subtests, ~8s wall time. Each spawns its own altair subprocess with a per-test config (WithUpstreamTimeout(300ms) for the timeout test, WithMaxRequestBodySize(16B) for the cap test, etc.) so behaviours don't have to share an instance.

Phase A — gateway only (no MySQL):

  • T1 forwarding_unauthed — method/path/X-Request-Id arrive at upstream
  • T2' proxy_host_injected — proxy.host arrives as upstream Host header
  • T3' body_size_cap_rejects — 413 before upstream is dialed
  • T4' upstream_timeout_fires — 502 within ~300ms against a 3s-sleeping upstream
  • T5' no_body_cap_default — 4KB POST round-trips when cap=0

Phase B — oauth + MySQL:

  • T2 oauth_happy_path — issued bearer + matching scope reaches upstream
  • T3 oauth_missing_token — 4xx, no upstream contact
  • T4 oauth_invalid_token — 4xx
  • T5 oauth_wrong_scope — 4xx
  • T6 oauth_body_and_headers — POST round-trips body + custom header

CI

New smoke job in .github/workflows/general.yml with services: mysql:5.7. needs: verify so we don't burn CI minutes on broken PRs. Build-tagged via //go:build e2e, so make test (the unit suite + Coveralls path) is unchanged.

Test plan

  • CI verify green
  • CI lint green
  • CI test (Coveralls) green
  • CI smoke green — proves binary scaffolds, boots, forwards, rejects oversized bodies, fires timeouts, issues + validates oauth tokens
  • CI govulncheck / codeql / trivy green
  • Manual: cp env.sample .env && docker compose --env-file .env up -d then make smoke → 10 PASS

🤖 Generated with Claude Code

insomnius and others added 3 commits May 2, 2026 15:48
Introduce a `proxy:` block in app.yml that consolidates the gateway's
forwarding behaviour and protects against three real production
failure modes the previous code was exposed to:

  proxy:
    host: www.local.host         # was top-level proxy_host (still honored)
    upstream_timeout: 30s        # NEW — bound the entire upstream round-trip
    max_request_body_size: 10MB  # NEW — opt-in cap, 413 before dialing upstream

Why each knob:
- `upstream_timeout`: the previous proxy used `http.Client{}` per request
  with no timeout. A single hung upstream leaked a goroutine and an FD
  forever. Default 30s, configurable per deployment.
- `max_request_body_size`: previously unbounded. A client sending a
  500MB body buffered ~500MB of heap before the gateway noticed.
- `host`: was read via `os.Getenv("PROXY_HOST")` per request. Captured
  once at Generator construction so config + runtime agree on one
  source of truth.

Also: stream upstream responses with `io.Copy` instead of
`io.ReadAll` + `Writer.Write` — kills the per-request memory spike
proportional to response body size.

Backwards compatibility: top-level `proxy_host:` still works. If both
`proxy.host` and `proxy_host:` are set, `proxy.host` wins so operators
can stage the migration. New apps generated by `altair new` use the
nested block.

Cleanup: drop `core/migrator.go` and `core/provider.go` — the plugin
registry refactor superseded `Migrator`, `MigrationProvider`,
`MigrationProviderDispatcher`, and `PluginProviderDispatcher`. Zero
production callers remained.

Also fix env.sample's broken `DATABASE_USERNAME=root` (MySQL 5.7's
entrypoint refuses MYSQL_USER=root and crash-loops the container).

Test coverage: every new behaviour is pinned by a test before the
implementation lands per the TDD rule in CLAUDE.md. Router coverage
held at 90.1%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a build-tagged smoke suite that drives the actual altair binary
end-to-end: builds it, runs `altair new` to scaffold, spawns it as a
subprocess, exchanges oauth tokens via the basic-auth-gated plugin
admin endpoint, and asserts forwarding + auth + the new proxy options
behave under a real HTTP roundtrip — not just at the function level.

Implements the spec at
docs/superpowers/specs/2026-04-23-altair-smoke-test-design.md.

Coverage matrix (10 subtests, ~8s wall time):

Phase A — gateway-only (no MySQL):
  T1  forwarding_unauthed       method/path/X-Request-Id propagated
  T2' proxy_host_injected       proxy.host arrives as upstream Host
  T3' body_size_cap_rejects     413 before dialing upstream
  T4' upstream_timeout_fires    502 within ~300ms vs 3s upstream
  T5' no_body_cap_default       4KB POST round-trips when cap=0

Phase B — oauth + MySQL:
  T2  oauth_happy_path          bearer + scope -> upstream reached
  T3  oauth_missing_token       4xx, no upstream hit
  T4  oauth_invalid_token       4xx
  T5  oauth_wrong_scope         4xx (scope mismatch)
  T6  oauth_body_and_headers    POST round-trip with header + body

Architecture:
- e2e/harness/{ports,upstream,harness,oauth}.go — subprocess
  orchestrator, in-process echo upstream, free-port allocator, oauth
  application seeder. All `//go:build e2e` tagged.
- e2e/{smoke,oauth_smoke}_test.go — 10 subtests; each spawns its own
  altair instance so config-level differences (timeout, body cap)
  don't require a single bloated harness.
- Makefile: `make smoke` opt-in, `make test` excludes /e2e.
- CI: new `smoke` job in general.yml with `services: mysql:5.7`. No
  docker-in-docker; native runner-side service.

The mock upstream honors r.Context() during sleeps so a gateway-side
timeout cancellation drains the goroutine immediately — without this,
httptest.Server.Close() blocked the test teardown for the full sleep
duration (3s -> 0.5s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's golangci-lint uses misspell with US locale. Three doc comments
slipped through with the British spelling — the linter is stricter
than CLAUDE.md noted (US locale per repo conventions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@insomnius
insomnius merged commit 090b5bb into master May 23, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant